Skip to content

Hand Arrow the buffers the engine already filled - #23

Merged
tamnd merged 1 commit into
mainfrom
columnar-arrow
Aug 19, 2026
Merged

Hand Arrow the buffers the engine already filled#23
tamnd merged 1 commit into
mainfrom
columnar-arrow

Conversation

@tamnd

@tamnd tamnd commented Aug 19, 2026

Copy link
Copy Markdown
Owner

docs/clients/duckdb.md measured to_arrow on a million rows at twenty times DuckDB's, and traced all of it to one thing: a result is rows, and src/columns.rs was the code that turned it back into columns. It walked the whole result once per column to settle a type, then once per column per batch to gather a Vec of pointers, then built an Arrow array by collecting an iterator of Options into it. Three passes over three million boxed values, and a copy at the end of them.

The engine reads a result down its columns now. zudb::query::column does it in two passes over the rows in row order and hands back one owned buffer per column in the layout Arrow already uses: values end to end with a null still occupying its cell, a validity bitmap that is absent when nothing is null, strings as bytes and offsets that stay narrow until the bytes pass what a 32 bit offset addresses.

So what is left in this file is a translation. An integer column is Int64Array::new(ScalarBuffer::from(values), nulls) and the Vec moves; so do floats, dates, times, datetimes, day-time durations, the packed bits of a boolean column, and the bytes and offsets of a string one. Nothing is copied and nothing is walked. The two that still are: a year-month duration, because Arrow counts an interval's months in 32 bits and the engine counts them in 64, and the complex types, which have no buffer and arrive as borrowed values. Nodes, rels, paths, lists and records are built exactly as they were, and they are also the columns nobody exports a million of.

Both refusals that were here stay here, because they are Arrow's facts rather than the engine's. A time with an offset has no Arrow type and dropping the offset would move the value. A handle to a graph or a binding table is a reference to something that is not in the frame. The mixed column message comes from the engine now and says the same thing in the same shape.

record_batches stops being worse than its name promised. It used to build every batch into a Vec and then hand back a reader over it, so a caller who asked for batches got the whole Arrow table built first and the rows still alive beside it. A batch is a slice of the finished column now, cut when the reader asks for it.

What it measures

tools/versus_duckdb.py is in this PR so the numbers can be reproduced rather than believed. A million rows of an integer, a double and a five byte string, in a stored table on both sides, release build, fastest of five, DuckDB 1.5.5.

call was is against DuckDB
execute and Arrow table 148 ms 73 ms 10.8x slower to 4.9x slower
execute and pandas 148 ms 79 ms 3.1x slower to 1.5x slower

The statement itself is 45 ms of each of those, so the export went from 103 ms to 26 ms. Of the 26, the engine's own transpose is 22, which is what the columnar bench in the engine tree times. The Arrow half is about four milliseconds now. The rest goes when the sink stops flattening its vectors into rows, which is the second half of item 1 in the audit.

Two things the script found that are worth writing down. DuckDB's arrow() hands back a RecordBatchReader that has read nothing, so it times at nought and is not the same call as ours; to_arrow_table is, and that is what is compared. And register is nine times faster than DuckDB's for a frame of numbers and four times slower for one with a string column in it, because the string bytes get a UTF-8 validation pass on the way in and the numbers get nothing. Both rows are printed rather than the average of them.

Tests

597 passed, 5 skipped. Six of them are new: a null in the middle of an integer, a float, a boolean and a string column, which is the validity bitmap and, for the boolean one, the only place two different bit packings sit next to each other; and a column with nothing missing carrying no bitmap at all, which is Arrow's convention and the engine's.

The engine pin moves to a1f310c, which is the commit that added zu::query::column.

docs/clients/duckdb.md measured to_arrow on a million rows at twenty
times DuckDB's, and it traced all of it to one thing: a result is rows,
and this file was the code that turned it back into columns. It walked
the whole result once per column to settle a type, then once per column
per batch to gather a Vec of pointers, then built an Arrow array by
collecting an iterator of Options into it. Three passes over three
million boxed values, and a copy at the end of them.

The engine reads a result down its columns now. zudb::query::column
does it in two passes over the rows in row order, and hands back one
owned buffer per column in the layout Arrow already uses: values end to
end with a null still occupying its cell, a validity bitmap that is
absent when nothing is null, strings as bytes and offsets that stay
narrow until the bytes pass what a 32 bit offset addresses. That is the
half of the work this file used to do badly, done once, in the place
that will eventually not have to do it at all.

So what is left here is a translation. An integer column is
Int64Array::new(ScalarBuffer::from(values), nulls) and the Vec moves;
so do floats, dates, times, datetimes, day-time durations, the packed
bits of a boolean column, and the bytes and offsets of a string one.
Nothing is copied and nothing is walked. The two that still are: a
year-month duration, because Arrow counts an interval's months in 32
bits and the engine counts them in 64, and the complex types, which
have no buffer and arrive as borrowed values. Nodes, rels, paths, lists
and records are built exactly as they were, and they are also the
columns nobody exports a million of.

Both refusals that were here stay here, because they are Arrow's facts
rather than the engine's. A time with an offset has no Arrow type and
dropping the offset would move the value. A handle to a graph or a
binding table is a reference to something that is not in the frame. The
mixed column message now comes from the engine, and says the same thing
in the same shape, which is what the tests that read it were pinning.

record_batches stops being worse than its name promised. It used to
build every batch into a Vec and then hand back a reader over it, so a
caller who asked for batches got the whole Arrow table built first and
the rows still alive beside it. A batch is a slice of the finished
column now, cut when the reader asks for it, which is a view and not an
allocation, and a consumer that stops early stops paying.

Measured with tools/versus_duckdb.py, which is in this commit so the
numbers can be reproduced rather than believed. A million rows of an
integer, a double and a five byte string, in a stored table on both
sides, release build, fastest of five, DuckDB 1.5.5.

  execute and Arrow table   148 ms -> 73 ms   10.8x -> 4.9x slower
  execute and pandas        148 ms -> 79 ms    3.1x -> 1.5x slower

The statement itself is 45 ms of each of those, so the export went from
103 ms to 26 ms. Of the 26, the engine's own transpose is 22, which is
what the columnar bench in the engine tree times. The Arrow half is
about four milliseconds now and there is not much left in it. The rest
goes when the sink stops flattening its vectors into rows, and that is
the second half of item 1 in the audit rather than anything this file
can do.

Two things the script found that are worth writing down. DuckDB's
arrow() hands back a RecordBatchReader that has read nothing, so it
times at nought and is not the same call as ours; to_arrow_table is,
and that is what is compared. And register is nine times faster than
DuckDB's for a frame of numbers and four times slower for one with a
string column in it, because the string bytes get a UTF-8 validation
pass on the way in and the numbers get nothing. Both rows are printed
rather than the average of them.

Six tests. A null in the middle of an integer, a float, a boolean and a
string column, which is the validity bitmap and, for the boolean one,
the only place two different bit packings sit next to each other. And a
column with nothing missing carrying no bitmap at all, which is Arrow's
convention and the engine's and is worth pinning because it is the case
a reader gets to skip work for.
@tamnd
tamnd merged commit eaeb9b8 into main Aug 19, 2026
33 checks passed
@tamnd
tamnd deleted the columnar-arrow branch August 19, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant